/* This file is part of swapper project
 *
 * Copyright (C) 2020 The Swapper Project Authors
 *
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
 * in compliance with the License. You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software distributed under the License
 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
 * or implied. See the License for the specific language governing permissions and limitations under
 * the License.
 */
package com.swapper.json.io;

import com.swapper.json.*;
import jdk.internal.math.FloatingDecimal;

import java.io.*;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.*;

/**
 * JSON lexer and parser.
 */
public final class JsonReader implements Closeable {
  /**
   * JSON token base mask code.
   */
  public static final int TOKEN_NONE = 0;
  public static final int TOKEN_NULL = 1;
  public static final int TOKEN_TRUE = 1 << 1;
  public static final int TOKEN_FALSE = 1 << 2;
  public static final int TOKEN_INTEGER = 1 << 3;
  public static final int TOKEN_DECIMAL = 1 << 4;
  public static final int TOKEN_STRING = 1 << 5;
  public static final int TOKEN_NAME = 1 << 6;
  public static final int TOKEN_BEGIN_ARRAY = 1 << 7;
  public static final int TOKEN_END_ARRAY = 1 << 8;
  public static final int TOKEN_BEGIN_OBJECT = 1 << 9;
  public static final int TOKEN_END_OBJECT = 1 << 10;
  public static final int TOKEN_VALUE_SEPARATOR = 1 << 11;
  public static final int TOKEN_NAME_SEPARATOR = 1 << 12;
  public static final int TOKEN_END_DOCUMENT = 1 << 13;

  /**
   * JSON token compose mask code
   */
  private static final int TOKEN_BOOLEAN = TOKEN_TRUE | TOKEN_FALSE;
  private static final int TOKEN_NUMBER = TOKEN_INTEGER | TOKEN_DECIMAL;
  private static final int TOKEN_END_STRUCT = TOKEN_END_ARRAY | TOKEN_END_OBJECT | TOKEN_END_DOCUMENT;

  /**
   * JSON value expected code:
   * value = null + true + false + number + string + [ + {
   */
  private static final int TOKEN_VALUE = TOKEN_NULL | TOKEN_BOOLEAN | TOKEN_NUMBER | TOKEN_STRING
    | TOKEN_BEGIN_ARRAY | TOKEN_BEGIN_OBJECT;

  /**
   * JSON array init expected code: array = ] + value
   * JSON array next expected code: array = ] + ,
   */
  private static final int TOKEN_ARRAY_INIT = TOKEN_END_ARRAY | TOKEN_VALUE;
  private static final int TOKEN_ARRAY_NEXT = TOKEN_END_ARRAY | TOKEN_VALUE_SEPARATOR;

  /**
   * JSON object init expected code: object = } + name
   * JSON object next expected code: object = } + ,
   */
  private static final int TOKEN_OBJECT_INIT = TOKEN_END_OBJECT | TOKEN_NAME;
  private static final int TOKEN_OBJECT_NEXT = TOKEN_END_OBJECT | TOKEN_VALUE_SEPARATOR;

  /**
   * Reader buffer maximum size.
   */
  private static final int MAX_BUFFER_SIZE = 8192;

  /**
   * The JSON input stream.
   */
  private final Reader reader;

  /**
   * The buffer index.
   */
  private int cursor;

  /**
   * Current buffer length.
   */
  private int size;

  /**
   * The read buffer for the stream.
   */
  private final char[] buffer = new char[MAX_BUFFER_SIZE];

  /**
   * The previous JSON token read from the stream.
   * Used to determine whether the next string is a JSON string or a JSON name.
   */
  private int peek;
  private String value;

  /**
   * Current path node stack.
   * Used to determine the current level and trace error messages.
   */
  private final JsonPath path = new JsonPath();

  /**
   * Read a JSON document(text) instance from the input stream.
   *
   * @return a JSON document(text) instance.
   * @throws JsonIOException if an I/O error occurs.
   */
  public static JsonDocument read(Reader in) {
    JsonDocument document;
    try {
      document = new JsonReader(in).read();
    } catch (IOException e) {
      throw new JsonIOException(e);
    }
    try {
      in.close();
    } catch (IOException e) {
      throw new JsonIOException(e);
    }
    return document;
  }

  /**
   * The constructor.
   *
   * @param in a JSON document(text) reader.
   * @throws NullPointerException if reader is null.
   */
  public JsonReader(Reader in) {
    reader = Objects.requireNonNull(in);
  }

  /**
   * Read a JSON document(text) instance from the input stream.
   *
   * @return a JSON document(text) instance.
   * @throws JsonIOException if an I/O error occurs.
   */
  public JsonDocument read() throws IOException {
    switch (tokenizing()) {
      case TOKEN_BEGIN_ARRAY:
        return nextArray();
      case TOKEN_BEGIN_OBJECT:
        return nextObject();
      default:
        throw new JsonSyntaxException("Parse error, JSON is not array or object!");
    }
  }

  public boolean hasNext() throws IOException {
    int token = peek();
    if (token == TOKEN_VALUE_SEPARATOR) {
      token = tokenizing();
    }
    return (token & TOKEN_END_STRUCT) == 0;
  }

  public void beginArray() throws IOException {
    int token = peek();
    if (token == TOKEN_BEGIN_ARRAY) {
      path.beginArray();
      peek = TOKEN_NONE;
    } else {
      throw new JsonSyntaxException("beginArray()" + currentPath());
    }
  }

  public void endArray() throws IOException {
    int token = peek();
    if (token == TOKEN_END_ARRAY) {
      path.endArray();
      peek = TOKEN_NONE;
    } else {
      throw new JsonSyntaxException("endArray()" + currentPath());
    }
  }

  public void beginObject() throws IOException {
    int token = peek();
    if (token == TOKEN_BEGIN_OBJECT) {
      path.beginObject();
      peek = TOKEN_NONE;
    } else {
      throw new JsonSyntaxException("beginObject()" + currentPath());
    }
  }

  public void endObject() throws IOException {
    int token = peek();
    if (token == TOKEN_END_OBJECT) {
      path.endObject();
      peek = TOKEN_NONE;
    } else {
      throw new JsonSyntaxException("endObject()" + currentPath());
    }
  }

  public String nextName() throws IOException {
    int token = peek();
    if (token == TOKEN_NAME) {
      path.nextName(value);
      peek = TOKEN_NONE;
      return value;
    } else {
      throw new RuntimeException("nextName()" + currentPath());
    }
  }

  public void nextNull() throws IOException {
    int token = peek();
    if (token == TOKEN_NULL) {
      if (path.inArray()) {
        path.nextIndex();
      }
      peek = TOKEN_NONE;
    } else {
      throw new RuntimeException("nextNull()" + currentPath());
    }
  }

  public boolean nextBoolean() throws IOException {
    int token = peek();
    if (token == TOKEN_TRUE) {
      if (path.inArray()) {
        path.nextIndex();
      }
      peek = TOKEN_NONE;
      return true;
    } else if (token == TOKEN_FALSE) {
      if (path.inArray()) {
        path.nextIndex();
      }
      peek = TOKEN_NONE;
      return false;
    } else {
      throw new RuntimeException("nextBoolean()" + currentPath());
    }
  }

  public String nextIntegerString() throws IOException {
    int token = peek();
    if (token == TOKEN_INTEGER) {
      if (path.inArray()) {
        path.nextIndex();
      }
      peek = TOKEN_NONE;
      return value;
    } else {
      throw new RuntimeException("nextIntegerString()" + currentPath());
    }
  }

  public String nextDecimalString() throws IOException {
    int token = peek();
    if (token == TOKEN_DECIMAL) {
      if (path.inArray()) {
        path.nextIndex();
      }
      peek = TOKEN_NONE;
      return value;
    } else {
      throw new RuntimeException("nextDecimalString()" + currentPath());
    }
  }

  public String nextNumberString() throws IOException {
    int token = peek();
    if (token == TOKEN_INTEGER || token == TOKEN_DECIMAL) {
      if (path.inArray()) {
        path.nextIndex();
      }
      peek = TOKEN_NONE;
      return value;
    } else {
      throw new RuntimeException("nextNumberString()" + currentPath());
    }
  }

  public String nextString() throws IOException {
    int token = peek();
    if (token == TOKEN_STRING) {
      if (path.inArray()) {
        path.nextIndex();
      }
      peek = TOKEN_NONE;
      return value;
    } else {
      throw new RuntimeException("nextString()" + currentPath());
    }
  }

  /**
   * Reads the next array in turn from the input stream.
   */
  private JsonArray nextArray() throws IOException {
    int token = peek();
    if (token != TOKEN_BEGIN_ARRAY) {
      throw new JsonSyntaxException("Parse error, JSON is not array!");
    }
    path.beginArray();
    int expected = TOKEN_ARRAY_INIT;
    List<JsonValue> values = new ArrayList<>();
    while ((token = tokenizing()) != TOKEN_END_DOCUMENT) {
      if ((expected & token) == 0) {
        throw new JsonSyntaxException("The JSON syntax error: expected mask is "
          + expected + ", but actual mask was " + token + ", at " + currentPath());
      }
      switch (token) {
        case TOKEN_NULL:
          values.add(JsonValue.NULL);
          expected = TOKEN_ARRAY_NEXT;
          break;
        case TOKEN_TRUE:
          values.add(JsonValue.TRUE);
          expected = TOKEN_ARRAY_NEXT;
          break;
        case TOKEN_FALSE:
          values.add(JsonValue.FALSE);
          expected = TOKEN_ARRAY_NEXT;
          break;
        case TOKEN_INTEGER:
          values.add(JsonNumber.valueOf(new BigInteger(value)));
          expected = TOKEN_ARRAY_NEXT;
          break;
        case TOKEN_DECIMAL:
          values.add(JsonNumber.valueOf(new BigDecimal(value)));
          expected = TOKEN_ARRAY_NEXT;
          break;
        case TOKEN_STRING:
          values.add(JsonString.valueOf(value));
          expected = TOKEN_ARRAY_NEXT;
          break;
        case TOKEN_BEGIN_ARRAY:
          values.add(nextArray());
          expected = TOKEN_ARRAY_NEXT;
          break;
        case TOKEN_END_ARRAY:
          path.endArray();
          return new JsonArray(values);
        case TOKEN_BEGIN_OBJECT:
          values.add(nextObject());
          expected = TOKEN_ARRAY_NEXT;
          break;
        case TOKEN_VALUE_SEPARATOR:
          path.nextIndex();
          expected = TOKEN_VALUE;
          break;
        default:
          throw new JsonSyntaxException("Unexpected token when parse JSON array:"
            + token + " current path:" + currentPath());
      }
    }
    throw new JsonSyntaxException("Parse error when parse JSON array, parsing is incomplete.");
  }

  /**
   * Reads the next object in turn from the input stream.
   */
  private JsonObject nextObject() throws IOException {
    int token = peek();
    if (token != TOKEN_BEGIN_OBJECT) {
      throw new JsonSyntaxException("Parse error, JSON is not object!");
    }
    path.beginObject();
    String name = "";
    int expected = TOKEN_OBJECT_INIT;
    LinkedHashMap<String, JsonValue> members = new LinkedHashMap<>();
    while ((token = tokenizing()) != TOKEN_END_DOCUMENT) {
      if ((expected & token) == 0) {
        throw new JsonSyntaxException("The JSON syntax error: expected mask is "
          + expected + ", but actual mask was " + token + ", at " + currentPath());
      }
      switch (token) {
        case TOKEN_NULL:
          members.put(name, JsonValue.NULL);
          expected = TOKEN_OBJECT_NEXT;
          break;
        case TOKEN_TRUE:
          members.put(name, JsonValue.TRUE);
          expected = TOKEN_OBJECT_NEXT;
          break;
        case TOKEN_FALSE:
          members.put(name, JsonValue.FALSE);
          expected = TOKEN_OBJECT_NEXT;
          break;
        case TOKEN_INTEGER:
          members.put(name, JsonNumber.valueOf(new BigInteger(value)));
          expected = TOKEN_OBJECT_NEXT;
          break;
        case TOKEN_DECIMAL:
          members.put(name, JsonNumber.valueOf(new BigDecimal(value)));
          expected = TOKEN_OBJECT_NEXT;
          break;
        case TOKEN_STRING:
          members.put(name, JsonString.valueOf(value));
          expected = TOKEN_OBJECT_NEXT;
          break;
        case TOKEN_NAME:
          name = value;
          path.nextName(name);
          expected = TOKEN_NAME_SEPARATOR;
          break;
        case TOKEN_BEGIN_ARRAY:
          members.put(name, nextArray());
          expected = TOKEN_OBJECT_NEXT;
          break;
        case TOKEN_BEGIN_OBJECT:
          members.put(name, nextObject());
          expected = TOKEN_OBJECT_NEXT;
          break;
        case TOKEN_END_OBJECT:
          path.endObject();
          return new JsonObject(members);
        case TOKEN_VALUE_SEPARATOR:
          expected = TOKEN_NAME;
          break;
        case TOKEN_NAME_SEPARATOR:
          expected = TOKEN_VALUE;
          break;
        default:
          throw new JsonSyntaxException("Unexpected token when parse JSON object:"
            + token + " current path:" + currentPath());
      }
    }
    throw new JsonSyntaxException("Parse error when parse JSON object, parsing is incomplete.");
  }

  public int peek() throws IOException {
    int token = peek;
    if (token == TOKEN_NONE) {
      token = tokenizing();
    }
    return token;
  }

  /**
   * Returns the character at the next position from the reader.
   *
   * @return the character at the next position from the reader.
   * @throws IOException If an I/O error occurs.
   */
  private char next() throws IOException {
    if (cursor >= size) {
      if (size == 0) {
        size = reader.read(buffer);
        if (size == -1) {
          throw new EOFException("The input stream is empty.");
        }
      } else {
        buffer[0] = buffer[cursor - 1];
        size = reader.read(buffer, 1, MAX_BUFFER_SIZE - 1);
        if (size == -1) {
          return 0;
        }
        size += cursor = 1;
      }
    }
    return buffer[cursor++];
  }

  /**
   * Read a JSON token from JSON reader.
   * Note: use {@link this#peek} to receive the return value.
   *
   * @return a JSON token instance.
   */
  private int tokenizing() throws IOException {
    char input;
    do {
      input = next();
      if (size == -1) {
        if (!path.isEmpty()) {
          throw new EOFException("The input stream ends before full parsing.");
        }
        return peek = TOKEN_END_DOCUMENT;
      }
    } while (input == ' ' || input == '\n' || input == '\r' || input == '\t');
    switch (input) {
      case '{':
        return peek = TOKEN_BEGIN_OBJECT;
      case '}':
        return peek = TOKEN_END_OBJECT;
      case '[':
        return peek = TOKEN_BEGIN_ARRAY;
      case ']':
        return peek = TOKEN_END_ARRAY;
      case ',':
        return peek = TOKEN_VALUE_SEPARATOR;
      case ':':
        return peek = TOKEN_NAME_SEPARATOR;
      case 'n':
        return peek = readNullToken();
      case 't':
        return peek = readTrueToken();
      case 'f':
        return peek = readFalseToken();
      case '"':
        return peek = readNameOrStringToken();
      case '-':
        return peek = readNumberToken(1, input);
      case '0':
        return peek = readNumberToken(2, input);
      default:
        if (input >= '1' && input <= '9') {
          return peek = readNumberToken(3, input);
        }
        throw new JsonParseException("Illegal character at " + currentPath());
    }
  }

  /**
   * Read a JSON null token from reader.
   *
   * @return a JSON null token from reader.
   */
  private int readNullToken() throws IOException {
    if (next() == 'u' && next() == 'l' && next() == 'l') {
      return TOKEN_NULL;
    }
    throw new JsonParseException("Invalid null at " + currentPath());
  }

  /**
   * Read a JSON true token from reader.
   *
   * @return a JSON true token from reader.
   */
  private int readTrueToken() throws IOException {
    if (next() == 'r' && next() == 'u' && next() == 'e') {
      return TOKEN_TRUE;
    }
    throw new JsonParseException("Invalid true at " + currentPath());
  }


  /**
   * Read a JSON false token from reader.
   *
   * @return a JSON false token from reader.
   */
  private int readFalseToken() throws IOException {
    if (next() == 'a' && next() == 'l' && next() == 's' && next() == 'e') {
      return TOKEN_FALSE;
    }
    throw new JsonParseException("Invalid false at " + currentPath());
  }

  /**
   * Read a JSON number token from reader.
   *
   * @return a JSON number token from reader.
   */
  private int readNumberToken(int state, char input) throws IOException {
    StringBuilder builder = new StringBuilder();
    builder.append(input);
    while (true) {
      input = next();
      switch (state) {
        case 1:
          if (input >= '1' && input <= '9') state = 3;
          else if (input == '0') state = 2;
          else throw new JsonParseException("Unexpected minus sign at " + currentPath());
          break;
        case 2:
          if (input == '.') state = 4;
          else if (input == 'e' || input == 'E') state = 6;
          else {
            --cursor;
            value = builder.toString();
            return TOKEN_INTEGER;
          }
          break;
        case 3:
          while (input >= '0' && input <= '9') {
            builder.append(input);
            input = next();
          }
          if (input == '.') state = 4;
          else if (input == 'e' || input == 'E') state = 6;
          else {
            --cursor;
            value = builder.toString();
            return TOKEN_INTEGER;
          }
          break;
        case 4:
          if (input >= '0' && input <= '9') state = 5;
          break;
        case 5:
          while (input >= '0' && input <= '9') {
            builder.append(input);
            input = next();
          }
          if (input == 'e' || input == 'E') state = 6;
          else {
            --cursor;
            value = builder.toString();
            return TOKEN_DECIMAL;
          }
          break;
        case 6:
          if (input >= '0' && input <= '9') state = 8;
          else if (input == '-' || input == '+') state = 7;
          else throw new JsonParseException("Unexpected exponent at " + currentPath());
          break;
        case 7:
          if (input >= '0' && input <= '9') state = 8;
          else throw new JsonParseException("Incomplete exponent at " + currentPath());
          break;
        case 8:
          while (input >= '0' && input <= '9') {
            builder.append(input);
            input = next();
          }
          --cursor;
          value = builder.toString();
          return TOKEN_DECIMAL;
        default:
          throw new JsonSyntaxException("Illegal number at " + currentPath());
      }
      builder.append(input);
    }
  }

  /**
   * Read a JSON name or string token from reader.
   *
   * @return a JSON name or string token from reader.
   */
  private int readNameOrStringToken() throws IOException {
    int state = 1;
    StringBuilder builder = new StringBuilder();
    while (true) {
      char input = next();
      switch (state) {
        case 1:
          if (input == '\\') state = 2;
          else if (input == '"') {
            value = builder.toString();
            return peek == TOKEN_NAME_SEPARATOR || path.inArray() ? TOKEN_STRING : TOKEN_NAME;
          } else {
            builder.append(input);
          }
          break;
        case 2:
          state = 1;
          switch (input) {
            case '"':
              builder.append('"');
              break;
            case '\\':
              builder.append('\\');
              break;
            case '/':
              builder.append('/');
              break;
            case 'b':
              builder.append('\b');
              break;
            case 'f':
              builder.append('\f');
              break;
            case 'n':
              builder.append('\n');
              break;
            case 'r':
              builder.append('\r');
              break;
            case 't':
              builder.append('\t');
              break;
            case 'u':
              state = 3;
              break;
            default:
              throw new JsonParseException("Unsupported escape characters at " + currentPath());
          }
          break;
        case 3:
          int unicode = 0;
          for (int i = 0; i < 4; ++i) {
            unicode <<= 4;
            if (input >= '0' && input <= '9') {
              unicode += (input - '0');
            } else if (input >= 'a' && input <= 'f') {
              unicode += (10 + input - 'a');
            } else if (input >= 'A' && input <= 'F') {
              unicode += (10 + input - 'A');
            } else {
              throw new JsonParseException("Incomplete Unicode escape sequence at " + currentPath());
            }
            input = next();
          }
          state = 1;
          builder.append((char) unicode);
          break;
        default:
          throw new JsonParseException("Illegal string at " + currentPath());
      }
    }
  }

  /**
   * Gets the current path.
   * It is used to track where JSON errors occur.
   *
   * @return the current path.
   */
  public String currentPath() {
    return path.toString();
  }

  /**
   * Closes the stream and releases any system resources associated with it.
   *
   * @throws IOException If an I/O error occurs.
   * @see Closeable#close()
   */
  @Override
  public void close() throws IOException {
    reader.close();
  }

  /**
   * Gets the hash code of the current instance.
   *
   * @return the hash code of the current instance.
   * @see Object#hashCode()
   */
  @Override
  public String toString() {
    return reader.toString();
  }
}
